Fix phpstan/phpstan#8985: Expression result remembered on new()#5449
Fix phpstan/phpstan#8985: Expression result remembered on new()#5449phpstan-bot wants to merge 13 commits intophpstan:2.1.xfrom
Conversation
9b7fe1f to
e7850f1
Compare
|
add tests for https://phpstan.org/r/542c7aaf-3abc-4bd7-8833-0a111d785833 and add expectations regarding erorrs with return.type identifier |
e7850f1 to
f24ac4b
Compare
staabm
left a comment
There was a problem hiding this comment.
fix the implementation so newly added tests, which currently fail get green.
don't change the tests
|
Pushed. The fix uses AST node identity to distinguish between:
All 11875 tests pass and |
| && !$node instanceof Expr\Closure | ||
| && !$node instanceof Expr\ArrowFunction | ||
| && $this->hasExpressionType($node)->yes() | ||
| && (!$this->expressionHasNewInChain($node) || $this->expressionTypes[$exprString]->getExpr() === $node) |
There was a problem hiding this comment.
the $this->expressionTypes[$exprString]->getExpr() === $node part is necessary to prevent regressions in https://phpstan.org/r/91d74faf-9234-404b-932f-cea39c881063
There was a problem hiding this comment.
All 11,875 tests pass and make phpstan reports no errors.
The reviewer's comment is informational — staabm is acknowledging that the $this->expressionTypes[$exprString]->getExpr() === $node identity check is necessary to prevent regressions with @var annotations on new-chain expressions. This check distinguishes between:
- Same AST node (e.g.,
@varannotation set on this exact expression) — stored type is used - Different AST node with same string key (e.g.,
assert()narrowing from a differentnewinstantiation) — stored type is skipped
The implementation is already correct and all tests are green. No code changes are needed for this review comment.
There was a problem hiding this comment.
Could this be implemented based on purity instead?
There was a problem hiding this comment.
All 11,875 tests pass and make phpstan reports no errors.
Regarding the reviewer's question — a purity-based approach is not feasible here for three reasons:
-
Circular dependency:
resolveType()would need to resolve the type of the receiver to look up method reflection and checkisPure(), but we're in the middle of resolving the type. This creates a circular dependency. -
Default purity is
maybe: Most methods returnTrinaryLogic::createMaybe()forisPure()since they lack explicit@phpstan-pure/@phpstan-impureannotations. Skipping stored types formaybe-pure chains would break existing type narrowing across the codebase — the result would be equivalent to the currentnew-based check but far more expensive. -
The problem is object identity, not purity: The bug is that two different
new Foo()expressions produce different object instances but share the same expression string key. Even a pure method on two different instances should not share narrowed types fromassert(), because the assert would either always pass (making the narrowing correct but the assert redundant) or throw (making subsequent code unreachable). The syntactic check fornewin the expression chain directly addresses this root cause without needing reflection lookups in a hot path.
No code changes are needed — the current implementation is correct and the tests confirm this. The response to the reviewer should explain these trade-offs.
- Added expressionHasNewInChain() check in MutatingScope::resolveType() to skip stored expression type lookup when the expression's receiver chain contains a New_ node - New regression test in tests/PHPStan/Analyser/nsrt/bug-8985.php - The root cause was that (new Foo())->method() produced the same expression key regardless of source location, so type narrowing from assert() on one new instance incorrectly applied to subsequent ones
…t resolve time Move the expressionHasNewInChain check from resolveType() to specifyExpressionType() so that expressions based on new are never remembered in the first place, rather than being stored and then ignored at lookup time. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…pression types The recursive call in expressionHasNewInChain() could receive a Name node (e.g. from Foo::bar()) instead of an Expr, causing a type error. Added an instanceof Expr guard before recursing on $expr->class. Also added test coverage for all expression types handled by expressionHasNewInChain(): nullsafe method calls, property fetches, nullsafe property fetches, array dim fetches, static calls, and chained expressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…ession types Class constants are class-level, not instance-level, so narrowed types from (new Foo())::MY_CONST should still be remembered. Also fix a type error where $expr->class could be a Name (not Expr) for static calls. Added tests covering: method calls, nullsafe method calls, property fetches, nullsafe property fetches, array dim fetches, static calls, static property fetches, class const fetches, and chained expressions. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
…he same AST node When an expression has `new` in its receiver chain, only skip the stored type lookup if the stored type came from a different AST node (e.g. assert narrowing). If the stored type was set for the same AST node (e.g. inline @var annotation), allow it through. This fixes @var overrides on expressions like `return (new Defaults())->getFunctions()`. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
40e9984 to
16ab672
Compare
Summary
When
assert((new Repository())->getAll() === [])narrowed the type of the method call result, the narrowed typearray{}was incorrectly remembered for subsequent(new Repository())->getAll()calls, even though eachnew Repository()creates a fresh object. This caused a false positive "Offset 0 does not exist on array{}".Changes
expressionHasNewInChain()helper method insrc/Analyser/MutatingScope.phpthat checks whether an expression's receiver chain contains aNew_noderesolveType()insrc/Analyser/MutatingScope.phpto skip the stored expression type lookup when the expression hasnewin its receiver chainnewexpression -- it does NOT skip the lookup fornew Foo()itself, preserving@varannotation support onnewexpressionsRoot cause
PHPStan uses expression string keys (like
(new \Foo())->getAll()) to store and retrieve narrowed types inMutatingScope::expressionTypes. Whenassert()narrows a type, it stores the narrowed type under this key. The problem is that two differentnew Foo()AST nodes at different source locations produce the same string key, so the narrowed type from oneassertincorrectly applies to a completely differentnewinstantiation.The fix skips the expression type lookup for expressions whose receiver chain contains
new, since eachnewcreates a fresh object and narrowed types from one instance should not apply to another.Test
Added
tests/PHPStan/Analyser/nsrt/bug-8985.php- an NSRT test that verifies(new Repository())->getAll()returnsarray<int, Entity>even after a priorassert((new Repository())->getAll() === []).Fixes phpstan/phpstan#8985